02 - 写入路径:什么该被记下来
前置:01 篇的三种记忆分型与四个动作。
本篇回答:一段对话结束后,具体是谁、在什么时候、按什么规则决定哪几句话值得留下来。这一步决定了检索的天花板 —— 抽取时丢掉的信息,任何检索策略都找不回来。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| 抽取(extraction) | 用一次模型调用把对话变成若干条独立的事实陈述 |
| 巩固(consolidation) | 新抽出的事实和已有记忆比对,决定是新增、改写、作废还是丢弃 |
| 自包含(self-contained) | 一条记忆脱离原对话也读得懂。「他也喜欢」不自包含,「用户的哥哥也喜欢滑雪」自包含 |
| 观测日期 / 当前日期 | 抽取时用到的两个不同时间锚点:对话实际发生的日期,和跑抽取的日期。混用会把相对时间解析错 |
| 幻觉 ID | 让模型直接操作 UUID 时,它会编出一个格式正确但不存在的 ID。规避手段见 2.2 |
一、两条路线:抽取还是原样存
MemDelta 那个数字值得单独说一句:在受控对照下,让 Agent 自己决定记什么(42%),反而不如把对话原样切片做向量检索(47%)。这不是说抽取式没用,而是说抽取带来的收益不像宣传材料里那么确定,而它的成本是确定的。完整 实验设计在 07 篇第四节。
二、mem0 的写入流水线
mem0 是抽取式路线里代码最完整、被读得最多的实现。下面按主分支 mem0/memory/main.py 的 _add_to_vector_store 逐段拆。
2.1 阶段②:检索已有记忆的两个约束
# mem0/memory/main.py,_add_to_vector_store 内
# Phase 1: Existing memory retrieval
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
query_embedding = self.embedding_model.embed(parsed_messages, "search")
existing_results = self.vector_store.search(
query=parsed_messages,
vectors=query_embedding,
top_k=10, # 硬编码:模型最多能看到十条已有记忆
filters=search_filters, # 三个租户维度,缺一个就会跨范围检索
)
两点要看清楚:
filters决定隔离边界。user_id/agent_id/run_id三个维度,调用方漏传就退化成全库检索。多租户系统里这是一个真实的越界风险,07 篇第三节展开- 查询用的是整段对话的嵌入,不是逐条事实的嵌入。对话涉及多个话题时,这个"平均向量"可能哪个话题都不太像,检索出来的十条已有记忆跟真正要写的事实关系不大
2.2 阶段③:UUID 要先映射成整数
这段代码上面挂着一行注释 # Map UUIDs to integers (anti-hallucination):
existing_memories = []
uuid_mapping = {}
for idx, mem in enumerate(existing_results):
uuid_mapping[str(idx)] = mem.id # 记下 "0" → "a3f1c8e2-..." 的对照
existing_memories.append({"id": str(idx), "text": mem.payload.get("data", "")})
# 给模型看到的是 [{"id": "0", "text": "..."}, ...],不是真正的 UUID
为什么必须这么做:直接把 UUID 给模型,让它回答"这条新事实和哪条旧记忆有关",它会返回一个格式完全正确、但库里不存在的 UUID。32 位十六进制串对模型来说是高熵噪声,复制过程中出错的概率远高于复制一个个位数。
映射成 "0"–"9" 之后,模型的输出空间被压到十个值,越界立刻能校验出来。这是一条通用经验:任何需要模型引 用现有实体的场合,都不要让它直接搬运长标识符。
2.3 infer=False:绕过抽取的旁路
def _add_to_vector_store(self, messages, metadata, filters, infer, prompt=None):
if not infer:
# 不调模型,每条非 system 消息原样存一条记忆
for message_dict in messages:
if message_dict["role"] == "system":
continue
msg_embeddings = self.embedding_model.embed(message_dict["content"], "add")
mem_id = self._create_memory(message_dict["content"], {...}, per_msg_meta)
return returned_memories
# ...以下才是抽取流水线
这条旁路就是第一节的路线 B。它在两种场景下有用:
- 写入的内容已经是结构化事实(比如从业务事件流灌进来的),再抽一遍没有意义还会引入误差
- 成本敏感的批量灌数:一百万条历史消息全走抽取,是一百万次模型调用
三、从四操作巩固退回纯追加
mem0 论文里的写法是两阶段:抽取出候选事实之后,再让模型对照已有记忆决定做什么。提示词至今还在 mem0/configs/prompts.py 里:
DEFAULT_UPDATE_MEMORY_PROMPT = """You are a smart memory manager which controls the memory of a system.
You can perform four operations: (1) add into the memory, (2) update the memory,
(3) delete from the memory, and (4) no change.
...
- ADD: Add it to the memory as a new element
- UPDATE: Update an existing memory element
- DELETE: Delete an existing memory element
- NONE: Make no change (if the fact is already present or irrelevant)
"""
而主分支的默认路径已经不走它了。同一个文件里新增了:
# V3 Additive Extraction Prompt (ADD-only with memory linking)
ADDITIVE_EXTRACTION_PROMPT = """
# ROLE
You are a Memory Extractor ... Your sole operation is ADD: identify every piece of
memorable information and produce self-contained, contextually rich factual statements.
...
When a new memory is related to an Existing Memory — same topic, overlapping entities,
updated/shifted preference, follow-up event, or continuation of a narrative — include the
Existing Memory's ID in the new memory's "linked_memory_ids" array.
"""
为什么会退回去,从代码本身能读出三条理由:
- UPDATE 和 DELETE 是不可逆的。模型在只看到十条相关记忆的情况下判断"这条该删",判错就没有第二次机会
- 四操作要求模型同时做两件事:识别新事实、并对每条做决策。合并任务会同时拉低两者的准确率
- 纯追加天然幂等。同一段对话重跑一遍,最坏结果是多几条被哈希或语义去重挡掉的重复;四操作重跑一遍可能把上次刚建的条目删掉
代价被明确接受了:库只增不减,冲突并存。这就把压力全部转移到检索侧和作废机制上 —— 也就是 03 篇和 04 篇的主题。
四、写入时机:热路径还是后台
LangMem 的概念文档把这两种时机叫 active(热路径)和 background(后台),并列出了各自的代价:
| 时机 | 延迟影响 | 记忆生效速度 | 处理位 置 | 适合 |
|---|---|---|---|---|
| 热路径 | 高 | 立即 | 生成回复的同一轮内 | 关键上下文更新 |
| 后台 | 无 | 延迟 | 两次调用之间或之后 | 模式分析、摘要 |
默认选后台。热路径写入只在一种情况下值得:用户的当轮输入明确是在给系统下指令("以后都用中文回我"),且这条指令必须立刻生效。这类输入可以用一个轻量分类器识别出来单独走热路径,其余走后台。
五、抽取质量的三条硬要求
5.1 每条必须自包含
# ❌ 抽出来的记忆依赖原对话才能读懂
{"text": "他也喜欢这个"}
# 检索时命中了这一条,注入上下文之后模型看到的是一句没有指代对象的话,
# 结果是模型要么忽略它,要么按自己的猜测补全 —— 后者更糟。
# ✅ 指代全部解析掉
{"text": "用户的哥哥也喜欢滑雪"}
mem0 的提示词里专门给了一段"最近 k 条消息"就是为了解代词:## Last k Messages — Recent messages (up to 20) preceding New Messages. Use to resolve references and pronouns in New Messages.
5.2 相对时间必须锚定到绝对时间
这是抽取里最容易被忽略、后果又最持久的一条。mem0 的提示词把两个日期分得很开:
| 输入 | 含义 | 用途 |
|---|---|---|
| Observation Date | 对话实际发生的日期 | 唯一可以用来解析「昨天」「上周」的锚点 |
| Current Date | 跑抽取的日期,可能比对话晚几个月 | 不得用于解析对话里的相对时间 |
提示词里给的理由很直白:一条写着「用户上周去了巴黎」的记忆,六个月后读回来毫无意义;写成「用户在 2023 年 5 月 15 日那周去了巴黎」则永远成立。
批量灌历史数据时这条最容易踩:一次性把两年的聊天记录跑抽取,如果拿跑批的当天当锚点,两年里所有的「昨天」会全部解析到同一天。
5.3 抽取范围要明确到角色
mem0 的两版提示词在这一点上是反的,说明这是个需要按场景决定的选项:
# USER_MEMORY_EXTRACTION_PROMPT —— 只从用户消息抽
# [IMPORTANT]: GENERATE FACTS SOLELY BASED ON THE USER'S MESSAGES.
# DO NOT INCLUDE INFORMATION FROM ASSISTANT OR SYSTEM MESSAGES.
# ADDITIVE_EXTRACTION_PROMPT —— 两边都抽
# You extract from BOTH user and assistant messages. User messages reveal personal facts...
# Assistant messages contain recommendations, plans, suggestions...
只从用户消息抽:安全,不会把模型自己的幻觉固化成"事实"。代价是丢掉助手给出的方案和承诺 —— 用户下次问"你上次推荐的那家店"就找不到了。
两边都抽:能记住方案与承诺,但要防住模型给自己背书。新版提示词的防线是一条排除清单:不抽"你看起来很有热情"这类助手对用户的主观刻画,除非用户明确认可;不抽"好问题!"这类客套。
六、写入成本
按一次 2,000 token 的对话、抽出 3 条事实估算,用输入价格 15/MTok 的中档模型:
这笔开销在账单里默认算不出来 —— 抽取调用和业务调用用的是同一个 API key。要拆开,得在网关侧按调用来源打标,做法见可观测性 06 篇。
七、四种静默失效
写入路径出问题时通常不报错,只是记忆变少或变脏。四种最常见的:
| 失效 | 现象 | 根因 | 怎么发现 |
|---|---|---|---|
| 抽了个空 | 会话结束后一条记忆都没多 | 模型返回了空 facts 数组,或 JSON 解析失败被 except 吞掉 | 监控"每会话新增记忆数"的分布,长期为 0 的用户是信号 |
| 抽成流水账 | 记忆库里全是「用户问了一个问题」这类无信息条目 | 提示词没有约束"什么不该抽",模型倾向于多抽 | 抽样人读,或用另一个模型给每条记忆打"以后还用得上吗"的分 |
| 时间全塌到一天 | 所有记忆的时间描述指向跑批那天 | 拿 Current Date 当锚点解析相对时间 | 查记忆文本里的日期分布,出现尖峰 即是 |
| 跨租户串号 | 用户 A 读到了 B 的记忆 | filters 里少传一个维度,检索退化成全库 | 在存储层加租户列的强制约束,别只靠调用方传参 |
前三种是质量问题,第四种是事故。第四种不要靠代码评审防:在向量库或数据库层面把租户字段做成必填的分区键,调用方漏传时直接报错,而不是悄悄返回全部数据。
八、小结
- 抽取式的收益在受控评测里不像宣传的那么确定,而它的成本(模型开销约 1.7 倍)是确定的;生产系统常见做法是两条路线各存一份
- mem0 v2 的流水线只有一次模型调用,且把 UUID 映射成个位数整数给模型看 —— 后者是任何需要模型引用实体的场合都该抄的做法
- 从四操作巩固退回纯追加,本质是把"哪条才对"的判断从写入时推迟到检索时;不可逆操作在信息不全时代价太高
- 默认走后台写入,只给明确的指令类输入开热路径
- 抽取质量的三条硬要求:自包含、时间锚定到观测日期、明确抽取角色范围
下一篇:03 - 冲突、遗忘与时间,处理纯追加留下的那个问题 —— 库里两条矛盾的记忆并存时,谁说了算。
← 回到 专题索引